Skip to content

feat: History log logic for Components and Containers [FC-0123]#38178

Open
ChrisChV wants to merge 31 commits intoopenedx:masterfrom
open-craft:chris/FAL-4330-history-log
Open

feat: History log logic for Components and Containers [FC-0123]#38178
ChrisChV wants to merge 31 commits intoopenedx:masterfrom
open-craft:chris/FAL-4330-history-log

Conversation

@ChrisChV
Copy link
Copy Markdown
Contributor

@ChrisChV ChrisChV commented Mar 16, 2026

Description

Publish history groups: Pre-Verawood vs Post-Verawood

The openedx-content library added a direct field to PublishLogRecord starting in the Verawood release (openedx/openedx-core#539). This field changes how publish history groups are structured, so the endpoint handles both eras:

Pre-Verawood (PublishLogRecord.direct is None): When a container and its components are published together, each entity produces its own independent group, even though they share the same publish_log_uuid. For example, publishing a Unit with 3 components creates 4 separate groups. Each group has scope_entity_key set to that specific entity's key, which the frontend must pass to the entries endpoint to fetch that entity's individual changes.

Post-Verawood (PublishLogRecord.direct is not None): The direct field marks which entities the user explicitly clicked "Publish" on (direct=True) vs. which were pulled in as side effects (direct=False, e.g. a shared component published from a sibling container). In this era, all entities from the same PublishLog are merged into a single group, and direct_published_entities lists only the explicitly published items. The scope_entity_key is null — the frontend uses the current container key to fetch entries.

This design means the frontend does not need era awareness: it always uses group.scope_entity_key ?? currentContainerKey when calling the entries endpoint.

Functions

  • Implements python api and REST_API functions to get the history log for a component:
    • get_library_component_draft_history: Return the draft change history for a library component since its last publication.
    • get_library_component_publish_history: Return the publish history of a library component as a list of groups.
    • get_library_component_publish_history_entries: Return the individual draft change entries for a specific publish event.
    • get_library_component_creation_entry: Return the creation entry (who created it and when).
  • Implements python api and REST_API functions to get the history log for containers:
    • get_library_container_draft_history: Return the combined draft history for a container and all of its descendant components.
    • get_library_container_publish_history: Return the publish history of a container as a list of groups.
    • get_library_container_publish_history_entries: Return the individual draft change entries for the container entity in a specific publish event.
    • get_library_container_creation_entry: Return the creation entry for a container.
  • Which edX user roles will this change impact? "Course Author", "Developer".

List of features (TODOs):

  • History log for components:
    • Draft group
    • Publish groups
    • Creation entry
    • Edge case: Discard changes
    • Pre-Verawood components
    • Post-Verawood components
  • [/] History log for container:
    • Draft group
    • Publish groups:
      • Container changes groups.
      • Component changes groups.
      • Edge case: Component deleted (?).
    • Creation entry.
    • Edge case: Discard changes.
    • Pre-Verawood containers
    • Post-Verawood containers
  • Imported components

Supporting information

Testing instructions

Follow the testing instructions in openedx/frontend-app-authoring#2948

Deadline

Before the Verawood cut.

Other information

N/A

@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Mar 16, 2026
@openedx-webhooks
Copy link
Copy Markdown

openedx-webhooks commented Mar 16, 2026

Thanks for the pull request, @ChrisChV!

This repository is currently maintained by @openedx/wg-maintenance-openedx-platform.

Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

🔘 Get product approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.

Details
Where can I find more information?

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

When can I expect my changes to be merged?

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

@ChrisChV ChrisChV marked this pull request as draft March 16, 2026 23:19
@github-project-automation github-project-automation Bot moved this to Needs Triage in Contributions Mar 16, 2026
@ChrisChV ChrisChV changed the title feat: Python api and rest api for History log logic feat: Python api and rest api for History log logic [FC-0123] Mar 16, 2026
@mphilbrick211 mphilbrick211 added the FC Relates to an Axim Funded Contribution project label Mar 23, 2026
@mphilbrick211 mphilbrick211 moved this from Needs Triage to Waiting on Author in Contributions Mar 23, 2026
path('assets/', blocks.LibraryBlockAssetListView.as_view()),
path('assets/<path:file_path>', blocks.LibraryBlockAssetView.as_view()),
path('publish/', blocks.LibraryBlockPublishView.as_view()),
# Get the draft change history for this block
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Can these endpoints be moved to xblock/rest_api and abstract the library logic?

I'm afraid no, but we will probably need to implement something like this there, for course xblocks, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm afraid there's no time to do it now; we can do it when this is implemented for the courses.

ChrisChV added 6 commits April 6, 2026 15:33
* Add get_library_container_publish_history and
    get_library_container_publish_history_entries to the containers API,
    returning groups (container + descendant components) sorted newest-first
* Add entity_key field to LibraryPublishHistoryGroup so the frontend
    knows which entries endpoint to call per group; block_type is now
    nullable to support containers
* Replace per-block publish_history/<uuid>/entries/ route with a single
    library-level publish_history_entries/ endpoint that accepts entity_key
    and publish_log_uuid as query params and routes to the correct handler
…s and scope_entity_key [FC-0123]

Redesigns publish history groups to support the `direct` field on PublishLogRecord (added in Verawood). Key changes:

  - Replace `entity_key/title/block_type` in `LibraryPublishHistoryGroup`
    with `direct_published_entities: list[DirectPublishedEntity]`, each
    carrying `entity_key`, `title`, and `entity_type` (works for both
    components and containers).

  - Add `scope_entity_key: str | None` to each group so the frontend
    always knows which key to pass to the entries endpoint without needing
    era awareness (`group.scope_entity_key ?? currentContainerKey`).

  - Pre-Verawood (direct=None): one group per entity × publish event;
    `scope_entity_key` = that entity's key.

  - Post-Verawood (direct!=None): one merged group per PublishLog;
    `scope_entity_key` = null (frontend uses current container).

  - Rename `block_type` → `item_type` (non-nullable) in
    `LibraryHistoryEntry`; populate it for containers via
    `container_type.type_code` (e.g. "unit", "section").

  - Rename entries query param `entity_key` → `scope_entity_key`.

  - Add Pre-Verawood and Post-Verawood integration tests.
@ChrisChV ChrisChV changed the title feat: Python api and rest api for History log logic [FC-0123] feat: History log logic for Components and Containers [FC-0123] Apr 20, 2026
Comment thread requirements/edx/kernel.in Outdated
openedx-atlas # CLI tool to manage translations
openedx-calc # Library supporting mathematical calculations for Open edX
openedx-core # Open edX Core: Content, Tagging, and other foundational APIs
git+https://github.com/open-craft/openedx-learning.git@chris/FAL-4330-history-log#egg=openedx-core
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: Update this with the new version

@ChrisChV ChrisChV marked this pull request as ready for review April 21, 2026 02:16
@ChrisChV ChrisChV requested a review from rpenido April 22, 2026 18:30
Copy link
Copy Markdown
Contributor

@ormsbee ormsbee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still have a lot more to review, but I wanted to submit this partial review before I turned in for today. Will look through this more in the morning.

Pre-Verawood groups have exactly one entry (approximated from available data).
Post-Verawood groups have one entry per direct=True record in the PublishLog.
"""
entity_key: str # str(usage_key) for components, str(container_key) for containers
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why make this a str instead of a LibraryUsageLocatorV2?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated: 43567d8

Pre-Verawood (direct=None): one group per entity × publish event.
Post-Verawood (direct!=None): one group per unique PublishLog.
"""
publish_log_uuid: str
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar above, why not have this be a UUID?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated: fab0165

# Pre-Verawood: the specific entity key for this group (container or usage key).
# Post-Verawood container groups: None — frontend must use currentContainerKey.
# Component history (all eras): str(usage_key).
scope_entity_key: str | None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be a union of the acceptable key types?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated: 43567d8

Post-Verawood (direct!=None): one group per unique PublishLog.
"""
publish_log_uuid: str
published_by: object # AUTH_USER_MODEL instance or None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kdmccormick: What's the right way to do this if we want to be respectful of custom user models? I vaguely recall you went down this rabbit hole once.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I think openedx-platform has too many assumptions about our user model for custom user models to work anyway, so maybe we can just make this a User?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated: 50355ee

def resolve_contributors(users, request=None) -> list[LibraryHistoryContributor | None]:
"""
Convert an iterable of User objects (possibly containing None) to a list of
LibraryHistoryContributor.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please put more information in the docstring about how this function is expected to behave, such as:

  1. What ordering guarantees does it make?
  2. Will it remove duplicates?
  3. Will it return None entries in the output?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated: 73ad2c9

]


def resolve_change_action(old_version, new_version) -> str:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please annotate what types old_version and new_version are supposed to be.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated: cb03488

Comment on lines +185 to +186
Returns "renamed" when both versions exist and the title changed between
them; otherwise returns "edited" as the default action.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are creation and deletion out of scope?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I updated the code to return created and deleted entries in: cb03488

# Import here to avoid circular imports (container_metadata imports block_metadata).
from .container_metadata import library_container_locator # noqa: PLC0415

title = record.new_version.title if record.new_version else ""
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does that mean that deletion is going to show up in the log as:

"" was edited

Because the new_version is going to be null?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated: 140a7c8

try:
component = record.entity.component
return DirectPublishedEntity(
entity_key=str(LibraryUsageLocatorV2( # type: ignore[abstract]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is ignore[abstract] necessary?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LibraryUsageLocatorV2 implements all the abstract methods at runtime, but opaque_keys doesn't ship complete type stubs, so mypy can't verify the implementation and complains. The ignore is necessary here.

title=title,
entity_type=component.component_type.name,
)
except Component.DoesNotExist:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do checks with hasattr() for container vs. component, and branch accordingly. Having the container code path be an error handling fallback of component handling is awkward and will break when we have entities that are neither Components nor Containers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated: 8ead067

Copy link
Copy Markdown
Contributor

@ormsbee ormsbee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some more questions/suggestions on how LibraryHistoryContributor is handled.

"""
One entry in the history of a library component.
"""
changed_by: LibraryHistoryContributor | None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everywhere else in the API, changed_by is a User, but here it's a new kind of object. What if we renamed changed_by to contributor to make that distinction clearer?

Comment on lines +232 to +250
records = list(
content_api.get_entity_draft_history(component.publishable_entity)
.select_related("entity__component__component_type")
)
changed_by_list = resolve_contributors(
(record.draft_change_log.changed_by for record in records), request
)

entries = []
for record, changed_by in zip(records, changed_by_list, strict=False):
version = record.new_version if record.new_version is not None else record.old_version
entries.append(LibraryHistoryEntry(
changed_by=changed_by,
changed_at=record.draft_change_log.changed_at,
title=version.title if version is not None else "",
item_type=record.entity.component.component_type.name,
action=resolve_change_action(record.old_version, record.new_version),
))
return entries
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be made into something like this?

    draft_change_records = (
        content_api.get_entity_draft_history(component.publishable_entity)
        .select_related(
            "entity__component__component_type",
            "draft_change_log__changed_by",
        )
    )
    entries = []
    for record in draft_change_records:
        version = record.new_version if record.new_version is not None else record.old_version
        changed_by = record.draft_change_log.changed_by
        if changed_by:
            contributor = LibraryHistoryContributor.from_user(changed_by, request)
        else:
            contributor = None
        entries.append(LibraryHistoryEntry(
            contributor=contributor,
            changed_at=record.draft_change_log.changed_at,
            title=version.title if version is not None else "",
            item_type=record.entity.component.component_type.name,
            action=resolve_change_action(record.old_version, record.new_version),
        ))

    return entries

If caching is needed on the contributor construction, we could use a helper local fn like:

    @cache  # functools.cache
    def make_contributor(user):
        if user is None:
            return None
        return LibraryHistoryContributor.from_user(user, request)

    draft_change_records = (
        content_api.get_entity_draft_history(component.publishable_entity)
        .select_related(
            "entity__component__component_type",
            "draft_change_log__changed_by",
        )
    )
    entries = []
    for record in draft_change_records:
        version = record.new_version if record.new_version is not None else record.old_version
        entries.append(LibraryHistoryEntry(
            contributor=make_contributor(record.draft_change_log.changed_by),
            changed_at=record.draft_change_log.changed_at,
            title=version.title if version is not None else "",
            item_type=record.entity.component.component_type.name,
            action=resolve_change_action(record.old_version, record.new_version),
        ))

    return entries

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

FC Relates to an Axim Funded Contribution project open-source-contribution PR author is not from Axim or 2U

Projects

Status: Waiting on Author

Development

Successfully merging this pull request may close these issues.

6 participants